Electron 데스크톱 앱의 렌더러 오리진 선택(file, 커스텀 스킴, localhost http)

마지막 수정일 · 2026. 08. 20.

TL;DR

문제: Electron으로 웹앱을 데스크톱에 담을 때 loadFile()file://을 열면
window.open 팝아웃, localStorage, getUserMedia에러 없이 조용히 깨짐.
file:// 문서는 opaque origin이라 same-origin도 secure context도 아니기 때문임.

해결: 렌더러에 진짜 origin을 주는 것. 방법은 셋.

  1. 커스텀 스킴을 privileged로 등록해서 app://로 로드
  2. 로컬 http 서버(http://127.0.0.1:<고정포트>)로 번들을 서빙
  3. 원격 URL을 그냥 로드 (Slack 방식, 서버가 하나로 존재할 때만)

앱 셸은 2번(dev의 Vite 서버와 오리진 규칙이 같아짐), 사용자 데이터 파일은 1번(외부 프로세스가 못 건드림)이 무난한 조합.


file://이 왜 문제인가

Chromium에서 file:// 문서는 opaque origin임. 창마다 origin이 다르고 어떤 origin과도 same-origin이 아님.
그래서 웹 기능이 줄줄이 죽는데, 대부분 예외가 아니라 무반응으로 나타나서 원인 찾기가 오래 걸림.

  • window.open 후 부모↔자식 DOM 접근 불가 (same-origin 아님)
  • localStorage / IndexedDB 불안정 — origin이 opaque라 저장이 안 붙음
  • getUserMedia, 클립보드 등 secure context 요구 API 전부 거부
  • fetch, ES module, service worker 대부분 안 됨
  • 상대 경로 라우팅 깨짐 (/route가 디스크 루트를 가리킴)

실제 증상 예: dockview 같은 도킹 라이브러리에서 패널을 별도 창으로 빼면 에러 없이 원래 자리로 되돌아감.
window.open으로 만든 자식 창에 살아있는 DOM을 옮기려면 same-origin이어야 하는데 그게 안 되니까.

렌더러 로드 방식 4가지

  1. 순수 번들형loadFile('dist/index.html'), origin은 file://(opaque). Electron 튜토리얼 기본값. 위 문제 전부 떠안음
  2. 커스텀 스킴 번들형app://, vscode-file:// 같은 스킴을 등록. VS Code, Obsidian이 이 방식
  3. 로컬 http 번들형 — 번들을 http://127.0.0.1:34952로 직접 서빙
  4. 원격 SSBhttps://app.slack.com/...를 그대로 로드. Slack, Discord

2번과 3번은 목적이 같음. 번들은 로컬에 두되 진짜 origin만 확보하기.
규모 있는 데스크톱 앱은 예외 없이 1번에서 벗어나 있고, 방향이 로컬 오리진(2, 3)이냐 원격 오리진(4)이냐만 다름.

커스텀 스킴 vs 로컬 http

한 줄 차이: 로컬 http는 진짜 TCP 소켓을 여는 것이고, 커스텀 스킴은 Chromium 안에서 URL 요청을 가로채는 것.

외부 노출

커스텀 스킴은 그 Electron 앱 안에서만 도달 가능함. 다른 프로세스가 app://를 열 방법이 없음.
로컬 http는 머신 위 모든 프로세스가 접근 가능한 포트임. 사용자가 크롬에서 연 아무 웹페이지도
fetch('http://127.0.0.1:34952/...')를 시도할 수 있음. 그래서 방어 코드가 따라붙음(아래 항목).

origin 안정성

커스텀 스킴의 origin은 영구 고정.
로컬 http는 origin에 포트가 들어가므로 포트가 바뀌면 origin이 바뀌고 origin-scoped 저장소가 통째로 날아감.
도킹 레이아웃, 사용자 설정 같은 걸 localStorage/IndexedDB에 넣어뒀다면 그대로 유실됨.
그래서 ephemeral 포트를 쓰면 안 되고, 고정 포트를 여러 번 재시도한 다음에야 다른 포트로 넘어가야 함
(앱을 빨리 재시작하면 이전 인스턴스가 아직 포트를 안 놓은 상태일 수 있음).

기능 기본값

http는 웹 기본값을 공짜로 받음 — secure context, fetch, service worker, 쿠키, CORS, Range 요청 전부 표준대로.
커스텀 스킴은 능력을 미리 선언해야 하고 그것도 app.ready 에 해야 함.

ts
protocol.registerSchemesAsPrivileged([{
  scheme: "myapp",
  privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true },
}])

standard가 없으면 origin이 아예 안 생기고, stream이 없으면 video 태그가 파일 전체를 버퍼링해버림.
빠뜨렸을 때 증상이 엉뚱한 데서 나타나는 게 이 방식의 함정임.

dev 패리티

개발 중엔 Vite dev server(http://localhost:5173 등)에서 뜨는데, 프로덕션도 http://127.0.0.1:<port>면 오리진 규칙이 dev와 완전히 동일해짐.
커스텀 스킴으로 가면 dev는 http, prod는 app://라서 서로 다른 규칙에서 돌게 되고,
팝아웃 / getUserMedia / CSP 관련 버그가 "dev에선 되는데 패키징하면 안 되는" 형태로 나타남.
앱 셸을 로컬 http로 서빙하는 이유가 대체로 이거임.

기타

  • 오버헤드: 커스텀 스킴은 인프로세스, http는 루프백 + HTTP 파싱 한 번. 실사용에선 대개 무시할 수준
  • 디버깅: http는 curl로 밖에서 찔러볼 수 있고 커스텀 스킴은 불가능

로컬 http를 쓸 때 반드시 넣어야 할 것

포트를 여는 순간 이건 그냥 웹 서버라서, 다음이 빠지면 로컬 취약점이 됨.

  • server.listen(port, "127.0.0.1") — 반드시 루프백 바인딩. 기본값으로 두면 0.0.0.0이라 같은 네트워크에 열림
  • Host 헤더가 loopback인지 검사 — DNS rebinding 차단용. 외부 페이지가 자기 도메인을 127.0.0.1로 해석시켜 붙는 걸 막음
  • 경로 탈출 차단 — decodeURIComponentnormalize한 결과가 root 아래인지 확인. .., 절대경로, 인코딩 우회 전부 여기서 막힘
  • GET/HEAD 외 메서드는 405
  • 고정 포트 + 재시도(위 origin 안정성 항목)
  • Electron이 fs를 asar 안으로 해석해주므로 번들을 unpack할 필요는 없음

원격 SSB는 언제 쓰나

ssb는 site-specific browser의 약자고, 앱이 특정 사이트 하나만 여는 전용 브라우저가 되는 형태임.
Slack 데스크톱 앱이 이 방식인데, app.asar가 10MB대고 안에 든 html은 basic-auth-view.html, gpu-info.html 같은 셸 부속 화면뿐임.
채널 목록·메시지 UI는 매번 서버에서 받아옴. 그래서 앱 버전과 UI 기능 버전이 서로 무관하게 움직임.

판단 기준은 Electron이냐가 아니라 "권위 있는 오리진이 하나로 존재하는가".

  • 서버가 하나(SaaS) → 원격 SSB가 이득. UI 배포가 서버 배포로 끝남
  • 서버가 설치처마다 따로(온프레미스) → 손해. 받아올 원격이 애초에 없고,
    UI만 최신으로 밀면 각자 버전이 다른 백엔드와 API 계약이 어긋남(버전 스큐)
  • 오프라인에도 동작해야 함 → 무조건 로컬 번들. SSB의 대가가 "온라인 필수"임

추가로, UI를 원격화해도 네이티브 능력을 쓰는 코드는 셸에 그대로 남음.
하드웨어 토큰(PKCS#11), 로컬 SQLite, 다중 OS 창, 커스텀 프로토콜, 로컬 파일 저장 같은 걸 쓰고 있으면
원격화로 얻는 건 "UI 즉시 배포" 하나뿐이고 잃는 건 오프라인 동작 전체임.

용어 정리

같이 헷갈리는 것들.

  • 웹앱: 브라우저에서 URL로 접근하는 앱. UI 계층만 가리키는 말
  • PWA: 웹앱 + manifest/service worker로 설치 가능. Electron 앱은 PWA가 아님
  • 하이브리드 앱: 네이티브 앱 안에 WebView를 부분 삽입한 것. 전체가 웹이면 해당 없음
  • Electron 앱: Chromium + Node를 번들한 데스크톱 앱. 구현 사실 서술
  • SSB(site-specific browser): 특정 사이트 하나만 여는 전용 브라우저

"Slack은 웹앱이냐"에 대한 답 — UI는 웹앱이 맞고, 앱 자체는 그 웹앱 전용 브라우저임.
같은 Electron 앱이라도 로컬 번들형과 원격 SSB는 성질이 정반대라 뭉뚱그려 부르면 논의가 꼬임.


[^1]: 구체 사례는 deardent ts/apps/emr-v2 — 렌더러는 src/lib/electron-app/renderer-server.ts(로컬 http, 고정 포트 34952, Host 헤더 검사, 경로 탈출 검사), 미디어 파일은 src/main/media-protocol.ts(커스텀 스킴 + standard/secure/supportFetchAPI/stream). 원격 SSB 사례는 Slack 4.51.180 macOS 번들(app.asar 10.6MB에 https://app.slack.com/ssb/first만 하드코딩).